// 
//   Copyright © 2009 Jiří Zárevúcky <zarevucky.jiri@gmail.com>
//  
//   This program is free software: you can redistribute it and/or modify
//   it under the terms of the GNU Affero General Public License as
//   published by the Free Software Foundation, either version 3 of the
//   License, or (at your option) any later version.
//  
//   This program is distributed in the hope that it will be useful,
//   but WITHOUT ANY WARRANTY; without even the implied warranty of
//   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
//   GNU Affero General Public License for more details.
//  
//   You should have received a copy of the GNU Affero General Public License
//   along with this program.  If not, see <http://www.gnu.org/licenses/>.
//  
//  


using System;
using System.Net;
using System.Text;
using System.Net.Sockets;
using System.Threading;
using System.Collections.Generic;

using Anculus.Core;

using Galaxium.Protocol.Xmpp.Library.Xml;
using Galaxium.Protocol.Xmpp.Library.Core;
using Galaxium.Protocol.Xmpp.Library.Extensions;
using Galaxium.Protocol.Xmpp.Library.Extensions.Disco;

namespace Galaxium.Protocol.Xmpp.Library.Streams
{
	public partial class Socks5BytestreamManager: IBytestreamManager
	{
		private JabberID _proxy;
		private ManualResetEvent _proxy_waiter;
		private Dictionary<string, StreamReceivedHandler> _handlers;
		
		public string Identifier {
			get { return Namespaces.Socks5Bytestream; }
		}

		public Socks5BytestreamManager ()
		{
			_proxy_waiter = new ManualResetEvent (false);
			_handlers = new Dictionary<string, StreamReceivedHandler> ();
		}
		
		public void RequestStream (JabberID uid, string sid, StreamReceivedHandler handler)
		{
			if (Client == null)
				throw new InvalidOperationException ("Not attached to any client.");
			
			var thread = new Thread (() => {
			
				var hosts = new Dictionary<JabberID, StreamHost> ();
				
				// TODO: add client's own IP address to the streamhost list
				
				if (_proxy_waiter.WaitOne (10000, false)) {
					var proxy_host = GetProxyStreamHost (sid);
					hosts.Add (proxy_host.UID, proxy_host);
				}
				
				StreamHost used_host;
				try {
					used_host = hosts [OfferStreamHosts (uid, sid, hosts.Values)];
				}
				catch (QueryException e) {
					if (e.Condition == "not-acceptable") {
						Log.Error (e, "Bytestream target rejected the request.");
						handler (StreamRequestResult.Rejected, null);
					}
					else if (e.Condition == "item-not-found") {
						Log.Error (e, "Bytestream target couldn't connect to any streamhost.");
						// TODO: fallback to in-band
						handler (StreamRequestResult.ProxyError, null);
					}
					else {
						Log.Error (e, "Unexpected query error received.");
						handler (StreamRequestResult.UnknownFailure, null);
					}
					return;
				}
				
				var bytes = Encoding.UTF8.GetBytes (sid + (string) Client.UID + (string) uid);
				var dst = Utility.Cryptography.Sha1HexHash (bytes);
				
				Socket socket = null;
				try {
					socket = Socks5Proxy.ConnectAsClient (used_host.Address, used_host.Port,
					                                      dst, 0, null, null);
				}
				catch (ProxyConnectionException e) {
					Log.Error (e, "PROXY NEGOTIATION ERROR");
					handler (StreamRequestResult.ProxyError, null);
					return;
				}
				
				try { ActivateStream (uid, sid); }
				catch (Exception e) {
					socket.Close ();
					Log.Error (e, "Error in attempt to activate proxy connection.");
					handler (StreamRequestResult.ProxyError, null);
					return;
				}
				
				handler (StreamRequestResult.Success, new NetworkStream (socket, true));
			});
			thread.IsBackground = true;
			thread.Start ();
		}
			
		private void ActivateStream (JabberID uid, string sid)
		{
			var query = new Iq (IqType.Set);
			query.To = _proxy;
			var q = new Element (null, "query", Namespaces.Socks5Bytestream, "sid", sid);
			q.AppendChild (new Element ("activate").SetText (uid));
			query.AppendChild (q);
			Client.SendQuery (query, 30000);
		}
		
		private StreamHost GetProxyStreamHost (string sid)
		{
			if (_proxy == null) return null;
			
			var query = new Iq (IqType.Get, Namespaces.Socks5Bytestream);
			query.To = _proxy;
			query.Query ["sid"] = sid;
			try {
				var result = Client.SendQuery (query, 10000);
				return new StreamHost (result.Query.FirstChild ("streamhost", null));
			}
			catch (Exception e) {
				Log.Error (e, "Exception occured when negotiating the use of proxy.");
				return null;
			}
		}
		
		private JabberID OfferStreamHosts (JabberID uid, string sid, IEnumerable<StreamHost> hosts)
		{
			var query = new Iq (IqType.Set);
			query.To = uid;
			var q = new Element ("query", Namespaces.Socks5Bytestream);
			q ["sid"] = sid;
			q ["mode"] = "tcp";
			
			foreach (var host in hosts)
				q.AppendChild (host.ToXml ());
			
			query.AppendChild (q);
			
			var result = Client.SendQuery (query, -1);
			return result.Query.FirstChild ("streamhost-used", null) ["jid"];
		}
		
		public void ExpectStream (JabberID uid, string sid, StreamReceivedHandler handler)
		{
			if (Client == null)
				throw new InvalidOperationException ("Not attached to any client.");
			
			_handlers.Add (uid + '#' + sid, handler);
		}

		private void HandleQuery (Iq query)
		{
			var initiator = query.From;
			var target = query.To;
			var q = query.Query;
			var sid = q ["sid"];
			var udp_mode = q ["mode"] == "udp";
			
			if (sid == null)
				throw new QueryException ("modify", "bad-request", "No SID provided.");
			
			if (udp_mode)
				throw new QueryException ("cancel", "feature-not-implemented",
				                          "libStarLight doesn't support UDP transfer.");
			
			StreamReceivedHandler handler;
			
			if (!_handlers.TryGetValue (initiator + '#' + sid, out handler))
				throw new QueryException ("auth", "not-acceptable",
				                          "Defined SID wasn't expected.");
			
			_handlers.Remove (initiator + '#' + sid);
			
			var thread = new Thread (() => {
				
				JabberID used = null;
				var socks_connection = (Socket) null;
				
				foreach (var elm in q.EachChild ("streamhost")) {
					try {
						var host = new StreamHost (elm);
						if (host.ZeroConf != null) continue;  // TODO: implement zeroconf
						
						used = host.UID;
						var hash_bytes =
							Encoding.UTF8.GetBytes (sid + (string) initiator + (string) target);
						var hash =
							Utility.Cryptography.Sha1HexHash (hash_bytes);
						socks_connection =
							Socks5Proxy.ConnectAsClient (host.Address, host.Port, hash, 0, null, null);
						
						break;
					}
					catch {}
				}
				
				if (socks_connection == null) {
					var response = query.CreateErrorResponse ("item-not-found");
					Client.Send (response);
					handler (StreamRequestResult.ProxyError, null);
				}
				else {
					handler (StreamRequestResult.Success, new NetworkStream (socks_connection));
					
					var response = query.Response ();
					q = new Element ("query", Namespaces.Socks5Bytestream);
					q.AppendChild (new Element (null, "streamhost-used", null, "jid", used));
					response.AppendChild (q);
					Client.Send (response);
				}
				
			});
			thread.IsBackground = true;
			thread.Start ();
		}
		
		private void FindProxy ()
		{
			_proxy_waiter.Reset ();
			
			var browser = new ServiceBrowser (Client, Client.UID.Domain);
			browser.ErrorOccured += (s, e) => _proxy_waiter.Set ();
			browser.ServiceReceived += delegate (object sender, ServiceEventArgs e) {
				foreach (var identity in e.Service.Identities) {
					if (identity.Category == "proxy" &&
					    identity.Type == "bytestreams") {
						
						_proxy = e.Service.Jid;
						_proxy_waiter.Set ();
						Log.Info ("SOCKS5 proxy found: " + _proxy);
					}
				}
			};
			browser.Finished += (s, e) => _proxy_waiter.Set ();
			browser.Request ();
		}
		
		#region IAttachable implementation
		
		public void Attach (Client client)
		{
			Detach ();
			Client = client;
			Client.DefineSetQueryHandler (Identifier, HandleQuery);
			Client.RegisterFeature (Identifier);
			Client.ConnectionStateChanged += HandleConnectionStateChanged;
			if (Client.ConnectionState == ConnectionState.Connected)
				FindProxy ();
		}

		void HandleConnectionStateChanged (object sender, ConnectionStateEventArgs e)
		{
			if (e.State == ConnectionState.Connected)
				FindProxy ();
		}
		
		public void Detach ()
		{
			if (Client == null) return;
			Client.ConnectionStateChanged -= HandleConnectionStateChanged;
			Client.UnregisterFeature (Identifier);
			Client.DefineSetQueryHandler (Identifier, null);
			Client = null;
			_proxy = null;
		}
		
		public Client Client {
			get; private set;
		}
		
		#endregion

	}
}
